Cette baseline vise Ă tester la capacitĂ© dâun classifieur simple Ă reconnaĂźtre les classes du dataset NCT-CRC-HE-100K Ă partir de formes synthĂ©tiques issues de SSM.
Lâobjectif est mĂ©thodologique : Ă©valuer la sĂ©parabilitĂ© inter-classe Ă partir de reprĂ©sentations morphologiques simplifiĂ©es
SchĂ©ma: pipeline SSM â features â mĂ©triques FID/LPIPS, classifieur downstream
# ==========================================================
# đ Baseline morphologique â Statistical Shape Model (SSM)
# ==========================================================
# --- Imports systĂšme et chemins ---
from pathlib import Path
import os
# --- Détection du projet (exécution depuis notebooks/) ---
PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "..")).resolve()
DATAGENERATOR_PATH = PROJECT_ROOT / "p9dg"
# Dossiers globaux du projet
MODELS_DIR = PROJECT_ROOT / "models"
ARTIFACTS_DIR = PROJECT_ROOT / "artifacts"
SAMPLES_DIR = PROJECT_ROOT / "samples" / "04_baseline_ssm"
OUTPUTS_DIR = PROJECT_ROOT / "outputs" / "04_baseline_ssm"
CHECKPOINTS_DIR = PROJECT_ROOT / "checkpoints" / "04_baseline_ssm"
RUNS_DIR = PROJECT_ROOT / "runs" / "04_baseline_ssm"
DATA_ROOT = Path(os.getenv("DATA_ROOT", PROJECT_ROOT / "data")).resolve()
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", PROJECT_ROOT / "configs")).resolve()
# Création des dossiers
for d in [MODELS_DIR, ARTIFACTS_DIR, SAMPLES_DIR, OUTPUTS_DIR, CHECKPOINTS_DIR, RUNS_DIR, CONFIG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# --- Dossiers spécifiques à cette baseline ---
# Masques binaires : dans OUTPUTS_DIR pour cette baseline
MASKS_DIR = OUTPUTS_DIR / "masks_sitk"
MASKS_DIR.mkdir(parents=True, exist_ok=True)
# Features et artifacts : dans ARTIFACTS_DIR
FEATURES_DIR = ARTIFACTS_DIR / "04_baseline_ssm"
FEATURES_DIR.mkdir(parents=True, exist_ok=True)
# ModĂšles PCA : dans MODELS_DIR
SSM_MODELS_DIR = MODELS_DIR / "04_baseline_ssm"
SSM_MODELS_DIR.mkdir(parents=True, exist_ok=True)
# --- ContrĂŽle ---
print(f"â
PROJECT_ROOT : {PROJECT_ROOT}")
print(f"â
DATA_ROOT : {DATA_ROOT}")
print(f"â
CONFIG_DIR : {CONFIG_DIR}")
print(f"â
MODELS_DIR : {MODELS_DIR}")
print(f"â
ARTIFACTS_DIR : {ARTIFACTS_DIR}")
print(f"â
OUTPUTS_DIR : {OUTPUTS_DIR}")
print(f"â
MASKS_DIR : {MASKS_DIR}")
print(f"â
FEATURES_DIR : {FEATURES_DIR}")
print(f"â
SSM_MODELS_DIR : {SSM_MODELS_DIR}")
â PROJECT_ROOT : /workspace â DATA_ROOT : /workspace/data â CONFIG_DIR : /workspace/configs â MODELS_DIR : /workspace/models â ARTIFACTS_DIR : /workspace/artifacts â OUTPUTS_DIR : /workspace/outputs/04_baseline_ssm â MASKS_DIR : /workspace/outputs/04_baseline_ssm/masks_sitk â FEATURES_DIR : /workspace/artifacts/04_baseline_ssm â SSM_MODELS_DIR : /workspace/models/04_baseline_ssm
# ==========================================================
# đ§ Imports scientifiques et configuration gĂ©nĂ©rale
# ==========================================================
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import SimpleITK as sitk
from skimage.io import imread
from skimage.transform import resize
from sklearn.decomposition import PCA
from tqdm import tqdm
import random
import SimpleITK as sitk
import numpy as np
import pandas as pd
from tqdm import tqdm
from skimage.measure import regionprops, moments_hu
from skimage.feature import greycomatrix, greycoprops
from skimage.util import img_as_ubyte
# --- Style d'affichage ---
sns.set_theme(style="whitegrid", context="notebook")
plt.rcParams.update({
"figure.figsize": (6, 4),
"axes.titlesize": 13,
"axes.labelsize": 11,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"image.cmap": "gray"
})
# --- Fixer la graine aléatoire pour reproductibilité ---
SEED = 42
np.random.seed(SEED)
random.seed(SEED)
# --- Vérification GPU pour compatibilité torch (optionnelle) ---
try:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
except ImportError:
device = "cpu"
print(f"â
Environnement prĂȘt â seed={SEED}, device={device}")
â Environnement prĂȘt â seed=42, device=cuda
IMAGE_SIZE = 256
SAMPLES_PER_CLASS = 300
VAHADANE_ENABLE = True
# MASKS_DIR est déjà défini dans la cellule 1
# MASKS_DIR.mkdir(parents=True, exist_ok=True) # Déjà créé dans la cellule 1
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print(f"â
Device utilisé : {DEVICE}")
# --- Initialisation du dataset ---
# Utiliser DATAGENERATOR_PATH défini dans la cellule 1
import sys
if str(DATAGENERATOR_PATH) not in sys.path:
sys.path.append(str(DATAGENERATOR_PATH))
from histo_dataset import HistoDataset
train_ds = HistoDataset(
root_data=str(DATA_ROOT),
split="train",
output_size=IMAGE_SIZE,
pixel_range="imagenet",
balance_per_class=True,
thresholds_json_path=str(CONFIG_DIR / "seuils_par_classe.json"),
vahadane_enable=VAHADANE_ENABLE,
vahadane_target_path=str(DATA_ROOT / "NCT-CRC-HE-100K/TUM/TUM-ANVGTFCR.tif"),
vahadane_device=DEVICE,
samples_per_class_per_epoch=SAMPLES_PER_CLASS
)
â Device utilisĂ© : cuda đš RĂ©fĂ©rence Vahadane fixĂ©e : TUM-ANVGTFCR.tif â Seuils par classe chargĂ©s depuis : /workspace/configs/seuils_par_classe.json âïž Ăchantillonnage Ă©quilibrĂ© activĂ© (300 images / classe).
# ==========================================================
# 𧏠Génération contrÎlée de masques binaires (HistoDataset + SimpleITK)
# ==========================================================
import SimpleITK as sitk
from tqdm import tqdm
import torch, csv, random
from pathlib import Path
import numpy as np
# --- Fonction de conversion tensor â masque binaire ---
def create_mask_from_tensor(x_tensor):
"""Convertit un tenseur RGB normalisé en image SimpleITK binaire (seuillage Otsu)."""
img_np = (x_tensor.permute(1, 2, 0).numpy() * 255).astype(np.uint8)
img_sitk = sitk.GetImageFromArray(img_np, isVector=True)
gray = sitk.VectorIndexSelectionCast(img_sitk, 0)
return sitk.OtsuThreshold(gray, 0, 1)
# --- Génération et sauvegarde par classe ---
for cls_name, cls_idx in train_ds.class_to_idx.items():
out_dir = MASKS_DIR / cls_name
out_dir.mkdir(parents=True, exist_ok=True)
manifest_path = out_dir / "manifest.csv"
class_indices = [i for i, (ci, _) in enumerate(train_ds._epoch_indices) if ci == cls_idx]
random.shuffle(class_indices)
class_indices = class_indices[:SAMPLES_PER_CLASS]
rows = []
for i in tqdm(class_indices, desc=f"Génération {cls_name}", leave=False):
x, y, src_path = train_ds[i]
src_stem = Path(src_path).stem
mask = create_mask_from_tensor(x)
mask_path = out_dir / f"{src_stem}__mask.tif"
sitk.WriteImage(mask, str(mask_path))
rows.append({"mask_path": str(mask_path), "source_path": str(src_path)})
# --- Ăcriture du manifest CSV ---
with open(manifest_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["mask_path", "source_path"])
writer.writeheader()
writer.writerows(rows)
print(f"â
{len(rows)} masques + manifest : {manifest_path}")
print(f"\nâ
Tous les masques enregistrés dans : {MASKS_DIR}")
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/ADI/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/BACK/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/DEB/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/LYM/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/MUC/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/MUS/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/NORM/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/STR/manifest.csv
â 300 masques + manifest : /workspace/outputs/04_baseline_ssm/masks_sitk/TUM/manifest.csv â Tous les masques enregistrĂ©s dans : /workspace/outputs/04_baseline_ssm/masks_sitk
# ==========================================================
# đ Lecture des manifests gĂ©nĂ©rĂ©s
# ==========================================================
import pandas as pd
mask_manifests = list(MASKS_DIR.glob("*/manifest.csv"))
assert mask_manifests, f"Aucun manifest trouvé dans {MASKS_DIR}"
records = []
for manifest_path in mask_manifests:
cls_name = manifest_path.parent.name
df = pd.read_csv(manifest_path)
df["class"] = cls_name
records.append(df)
df_masks = pd.concat(records, ignore_index=True)
print(f"â
{len(df_masks)} masques détectés ({len(mask_manifests)} classes).")
display(df_masks.head())
â 2700 masques dĂ©tectĂ©s (9 classes).
| mask_path | source_path | class | |
|---|---|---|---|
| 0 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-QMANKP... | LYM |
| 1 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-DLVHPM... | LYM |
| 2 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-GQEFRV... | LYM |
| 3 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-WIKKPR... | LYM |
| 4 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-AMIQAK... | LYM |
# ==========================================================
# đ ContrĂŽle visuel appariĂ© via manifest (1 par classe)
# ==========================================================
import pandas as pd
import matplotlib.pyplot as plt
import SimpleITK as sitk
from PIL import Image
import numpy as np
import random
classes = list(train_ds.class_to_idx.keys())
pairs = []
for cls in classes:
cls_dir = MASKS_DIR / cls
manifest_path = cls_dir / "manifest.csv"
if not manifest_path.exists():
print(f"â ïž Pas de manifest pour {cls}")
continue
dfm = pd.read_csv(manifest_path)
if dfm.empty:
print(f"â ïž Manifest vide pour {cls}")
continue
row = dfm.sample(1, random_state=random.randint(0, 10_000)).iloc[0]
pairs.append((cls, Path(row["mask_path"]), Path(row["source_path"])))
n = len(pairs)
fig, axes = plt.subplots(nrows=n, ncols=2, figsize=(6, 3*n))
if n == 1:
axes = np.array([axes])
for i, (cls, mask_path, src_path) in enumerate(pairs):
# masque
m = sitk.ReadImage(str(mask_path))
m_np = sitk.GetArrayFromImage(m)
# image source (affiche la version du disque telle quelle)
img = Image.open(src_path).convert("RGB")
img_np = np.asarray(img) / 255.0
ax_img, ax_mask = axes[i]
ax_img.imshow(img_np); ax_img.set_title(f"{cls} â Image source\n{src_path.name}"); ax_img.axis("off")
ax_mask.imshow(m_np, cmap="gray"); ax_mask.set_title("Masque binaire (Otsu)"); ax_mask.axis("off")
plt.tight_layout(); plt.show()
# ==========================================================
# đŹ Analyse morphologique des masques binaires (SimpleITK)
# ==========================================================
import pandas as pd
import numpy as np
import SimpleITK as sitk
from tqdm import tqdm
from pathlib import Path
# --- Chargement des manifests existants ---
mask_manifests = list(MASKS_DIR.glob("*/manifest.csv"))
assert mask_manifests, f"Aucun manifest trouvé dans {MASKS_DIR}"
records = []
for manifest_path in mask_manifests:
cls_name = manifest_path.parent.name
df = pd.read_csv(manifest_path)
df["class"] = cls_name
records.append(df)
df_masks = pd.concat(records, ignore_index=True)
print(f"â
{len(df_masks)} masques détectés ({len(mask_manifests)} classes).")
# --- Fonction d'analyse morphologique sur un masque binaire ---
def analyze_mask_sitk(mask_path: str):
"""
Calcule des descripteurs morphologiques simples Ă partir d'un masque binaire.
"""
try:
mask = sitk.ReadImage(str(mask_path))
mask = sitk.Cast(mask, sitk.sitkUInt8)
# Composantes connectées
cc = sitk.ConnectedComponent(mask)
stats = sitk.LabelShapeStatisticsImageFilter()
stats.Execute(cc)
num_obj = stats.GetNumberOfLabels()
if num_obj == 0:
return {
"num_objects": 0,
"mean_area": 0,
"mean_roundness": 0,
"mean_perimeter": 0,
}
areas = [stats.GetPhysicalSize(l) for l in stats.GetLabels()]
roundness = [stats.GetRoundness(l) for l in stats.GetLabels()]
perimeters = [stats.GetPerimeter(l) for l in stats.GetLabels()]
return {
"num_objects": num_obj,
"mean_area": float(np.mean(areas)),
"mean_roundness": float(np.mean(roundness)),
"mean_perimeter": float(np.mean(perimeters)),
}
except Exception as e:
return {"num_objects": np.nan, "mean_area": np.nan, "mean_roundness": np.nan, "error": str(e)}
# --- Application sur tous les masques ---
results = []
for _, row in tqdm(df_masks.iterrows(), total=len(df_masks), desc="Analyse des masques"):
metrics = analyze_mask_sitk(row["mask_path"])
metrics.update({
"mask_path": row["mask_path"],
"source_path": row["source_path"],
"class": row["class"],
})
results.append(metrics)
df_morpho_sitk = pd.DataFrame(results)
# --- Nettoyage et sauvegarde ---
df_morpho_sitk = df_morpho_sitk.dropna(subset=["num_objects"])
out_csv = FEATURES_DIR / "morpho_features_sitk.csv"
df_morpho_sitk.to_csv(out_csv, index=False)
print(f"â
Analyse morphologique terminée ({len(df_morpho_sitk)} entrées).")
print(f"đ RĂ©sultats sauvegardĂ©s : {out_csv}")
display(df_morpho_sitk.head())
â 2700 masques dĂ©tectĂ©s (9 classes).
Analyse des masques: 100%|ââââââââââ| 2700/2700 [00:42<00:00, 63.84it/s]
â Analyse morphologique terminĂ©e (2700 entrĂ©es). đ RĂ©sultats sauvegardĂ©s : /workspace/artifacts/04_baseline_ssm/morpho_features_sitk.csv
| num_objects | mean_area | mean_roundness | mean_perimeter | mask_path | source_path | class | |
|---|---|---|---|---|---|---|---|
| 0 | 2261 | 16.077842 | 1.093535 | 15.950313 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-QMANKP... | LYM |
| 1 | 2443 | 14.354892 | 1.037458 | 16.142349 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-DLVHPM... | LYM |
| 2 | 2158 | 16.867470 | 1.087852 | 16.798845 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-GQEFRV... | LYM |
| 3 | 2180 | 15.982111 | 1.090215 | 16.246547 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-WIKKPR... | LYM |
| 4 | 2183 | 15.963812 | 1.043177 | 16.838003 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-AMIQAK... | LYM |
# ==========================================================
# đ§ Enrichissement morphologique (scikit-image)
# ==========================================================
import numpy as np
import pandas as pd
from tqdm import tqdm
from pathlib import Path
from skimage import measure
from skimage.feature import graycomatrix, graycoprops
from skimage.util import img_as_ubyte
from skimage.io import imread
from skimage.measure import moments_hu
def extract_skimage_features(mask_path: str):
"""
Calcule descripteurs avancés de forme et texture (Hu moments, GLCM, regionprops).
Compatible avec scikit-image >= 0.22.
"""
try:
# Lecture du masque binaire
mask_np = imread(mask_path)
if mask_np.ndim == 3:
mask_np = mask_np[..., 0] # garder un seul canal si RGB accidentellement
# Conversion stricte en binaire uint8
mask_np = (mask_np > 0).astype(np.uint8)
# --- Descripteurs de forme (regionprops) ---
props = measure.regionprops(mask_np)
if len(props) == 0:
return {
"mean_eccentricity": 0.0,
"mean_solidity": 0.0,
"mean_extent": 0.0,
"mean_perimeter": 0.0,
"hu_1": 0.0,
"hu_2": 0.0,
"glcm_contrast": 0.0,
"glcm_homogeneity": 0.0,
"glcm_energy": 0.0,
"glcm_correlation": 0.0,
}
ecc = np.mean([p.eccentricity for p in props])
solidity = np.mean([p.solidity for p in props])
extent = np.mean([p.extent for p in props])
perimeter = np.mean([p.perimeter for p in props])
# --- Moments invariants de Hu ---
hu = moments_hu(mask_np)
hu_feats = {f"hu_{i+1}": float(v) for i, v in enumerate(hu)}
# --- Texture GLCM (Gray-Level Co-Occurrence Matrix) ---
gray_u8 = img_as_ubyte(mask_np) # assure niveaux 0â255
glcm = graycomatrix(
gray_u8,
distances=[1],
angles=[0],
levels=256,
symmetric=True,
normed=True
)
glcm_feats = {
"glcm_contrast": float(graycoprops(glcm, "contrast")[0, 0]),
"glcm_homogeneity": float(graycoprops(glcm, "homogeneity")[0, 0]),
"glcm_energy": float(graycoprops(glcm, "energy")[0, 0]),
"glcm_correlation": float(graycoprops(glcm, "correlation")[0, 0]),
}
return {
"mean_eccentricity": ecc,
"mean_solidity": solidity,
"mean_extent": extent,
"mean_perimeter": perimeter,
**hu_feats,
**glcm_feats,
}
except Exception as e:
return {"error": str(e)}
# ==========================================================
# âïž Application parallĂšle sur tous les masques
# ==========================================================
from joblib import Parallel, delayed
import multiprocessing
# Nombre optimal de jobs = nb de cĆurs logiques (ou un peu moins)
N_JOBS = min(8, multiprocessing.cpu_count()) # tu peux ajuster
print(f"đ§© Extraction scikit-image avec {N_JOBS} cĆurs en parallĂšle...")
mask_paths = df_morpho_sitk["mask_path"].tolist()
classes = df_morpho_sitk["class"].tolist()
sources = df_morpho_sitk["source_path"].tolist()
# Parallélisation du calcul
feats_list = Parallel(n_jobs=N_JOBS, backend="loky", verbose=10)(
delayed(extract_skimage_features)(p) for p in mask_paths
)
# Reconstitution du DataFrame
df_skimage = pd.DataFrame(feats_list)
df_skimage["mask_path"] = mask_paths
df_skimage["class"] = classes
df_skimage["source_path"] = sources
print(f"â
Extraction terminée ({len(df_skimage)} échantillons).")
display(df_skimage.head())
đ§© Extraction scikit-image avec 8 cĆurs en parallĂšle...
[Parallel(n_jobs=8)]: Using backend LokyBackend with 8 concurrent workers. [Parallel(n_jobs=8)]: Done 2 tasks | elapsed: 0.9s [Parallel(n_jobs=8)]: Done 9 tasks | elapsed: 1.0s [Parallel(n_jobs=8)]: Done 16 tasks | elapsed: 1.0s [Parallel(n_jobs=8)]: Done 25 tasks | elapsed: 1.0s [Parallel(n_jobs=8)]: Batch computation too fast (0.18144463485083162s.) Setting batch_size=2. [Parallel(n_jobs=8)]: Done 34 tasks | elapsed: 1.0s [Parallel(n_jobs=8)]: Batch computation too fast (0.029778480529785156s.) Setting batch_size=4. [Parallel(n_jobs=8)]: Done 50 tasks | elapsed: 1.1s [Parallel(n_jobs=8)]: Done 72 tasks | elapsed: 1.1s [Parallel(n_jobs=8)]: Batch computation too fast (0.05525040626525879s.) Setting batch_size=8. [Parallel(n_jobs=8)]: Done 124 tasks | elapsed: 1.2s [Parallel(n_jobs=8)]: Batch computation too fast (0.12214183807373047s.) Setting batch_size=16. [Parallel(n_jobs=8)]: Done 216 tasks | elapsed: 1.3s [Parallel(n_jobs=8)]: Done 408 tasks | elapsed: 1.6s [Parallel(n_jobs=8)]: Done 648 tasks | elapsed: 1.9s [Parallel(n_jobs=8)]: Done 920 tasks | elapsed: 2.2s [Parallel(n_jobs=8)]: Done 1192 tasks | elapsed: 2.6s [Parallel(n_jobs=8)]: Done 1496 tasks | elapsed: 3.0s [Parallel(n_jobs=8)]: Done 1800 tasks | elapsed: 3.4s [Parallel(n_jobs=8)]: Done 2136 tasks | elapsed: 3.7s [Parallel(n_jobs=8)]: Done 2472 tasks | elapsed: 4.1s
â Extraction terminĂ©e (2700 Ă©chantillons).
[Parallel(n_jobs=8)]: Done 2700 out of 2700 | elapsed: 4.4s finished
| mean_eccentricity | mean_solidity | mean_extent | mean_perimeter | hu_1 | hu_2 | hu_3 | hu_4 | hu_5 | hu_6 | hu_7 | glcm_contrast | glcm_homogeneity | glcm_energy | glcm_correlation | mask_path | class | source_path | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0.164346 | 0.554721 | 0.554688 | 24339.571742 | 1.0 | 5.0 | 10.0 | 2.0 | 4.0 | 4.0 | 8.0 | 0.368183 | 0.815908 | 0.522799 | 0.254776 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | LYM | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-QMANKP... |
| 1 | 0.130816 | 0.535159 | 0.535110 | 26141.870299 | 2.0 | 4.0 | 8.0 | 8.0 | 64.0 | 16.0 | 0.0 | 0.408502 | 0.795749 | 0.510736 | 0.178925 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | LYM | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-DLVHPM... |
| 2 | 0.135736 | 0.555445 | 0.555420 | 24643.853055 | 2.0 | 4.0 | 9.0 | 1.0 | -3.0 | 0.0 | 0.0 | 0.363925 | 0.818038 | 0.524043 | 0.263155 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | LYM | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-GQEFRV... |
| 3 | 0.114574 | 0.531867 | 0.531631 | 23401.587940 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 | 0.365671 | 0.817165 | 0.519634 | 0.265758 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | LYM | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-WIKKPR... |
| 4 | 0.209037 | 0.531965 | 0.531754 | 24813.635667 | 2.0 | 4.0 | 13.0 | 5.0 | 29.0 | 8.0 | -28.0 | 0.375352 | 0.812324 | 0.517266 | 0.246239 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | LYM | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-AMIQAK... |
# --- Fusion SimpleITK + scikit-image ---
df_morpho_ext = pd.merge(df_morpho_sitk, df_skimage, on=["mask_path", "source_path", "class"], how="left")
out_csv_ext = FEATURES_DIR / "morpho_features_extended.csv"
df_morpho_ext.to_csv(out_csv_ext, index=False)
print(f"â
Descripteurs scikit-image ajoutés ({len(df_morpho_ext)} entrées).")
print(f"đ RĂ©sultats sauvegardĂ©s : {out_csv_ext}")
display(df_morpho_ext.head())
â Descripteurs scikit-image ajoutĂ©s (2866 entrĂ©es). đ RĂ©sultats sauvegardĂ©s : /workspace/artifacts/04_baseline_ssm/morpho_features_extended.csv
| num_objects | mean_area | mean_roundness | mean_perimeter_x | mask_path | source_path | class | mean_eccentricity | mean_solidity | mean_extent | ... | hu_2 | hu_3 | hu_4 | hu_5 | hu_6 | hu_7 | glcm_contrast | glcm_homogeneity | glcm_energy | glcm_correlation | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2261 | 16.077842 | 1.093535 | 15.950313 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-QMANKP... | LYM | 0.164346 | 0.554721 | 0.554688 | ... | 5.0 | 10.0 | 2.0 | 4.0 | 4.0 | 8.0 | 0.368183 | 0.815908 | 0.522799 | 0.254776 |
| 1 | 2443 | 14.354892 | 1.037458 | 16.142349 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-DLVHPM... | LYM | 0.130816 | 0.535159 | 0.535110 | ... | 4.0 | 8.0 | 8.0 | 64.0 | 16.0 | 0.0 | 0.408502 | 0.795749 | 0.510736 | 0.178925 |
| 2 | 2158 | 16.867470 | 1.087852 | 16.798845 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-GQEFRV... | LYM | 0.135736 | 0.555445 | 0.555420 | ... | 4.0 | 9.0 | 1.0 | -3.0 | 0.0 | 0.0 | 0.363925 | 0.818038 | 0.524043 | 0.263155 |
| 3 | 2180 | 15.982111 | 1.090215 | 16.246547 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-WIKKPR... | LYM | 0.114574 | 0.531867 | 0.531631 | ... | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 | 0.365671 | 0.817165 | 0.519634 | 0.265758 |
| 4 | 2183 | 15.963812 | 1.043177 | 16.838003 | /workspace/outputs/04_baseline_ssm/masks_sitk/... | /workspace/data/NCT-CRC-HE-100K/LYM/LYM-AMIQAK... | LYM | 0.209037 | 0.531965 | 0.531754 | ... | 4.0 | 13.0 | 5.0 | 29.0 | 8.0 | -28.0 | 0.375352 | 0.812324 | 0.517266 | 0.246239 |
5 rows Ă 22 columns
# ==========================================================
# đ§ Chargement & SVM RBF (grid lĂ©ger, macro-F1)
# ==========================================================
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, StratifiedKFold, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score, f1_score
import matplotlib.pyplot as plt
CSV_PATH = FEATURES_DIR / "morpho_features_extended.csv"
df = pd.read_csv(CSV_PATH)
target_col = "class"
drop_cols = ["mask_path", "source_path", "error"]
feature_cols = [c for c in df.columns if c not in drop_cols + [target_col] and np.issubdtype(df[c].dtype, np.number)]
df_clean = df[[target_col] + feature_cols].dropna().reset_index(drop=True)
X = df_clean[feature_cols].values
y = df_clean[target_col].values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
svm = Pipeline([
("scaler", StandardScaler()),
("clf", SVC(kernel="rbf", probability=False, random_state=42))
])
param_grid = {
"clf__C": [0.5, 1, 2, 5],
"clf__gamma": ["scale", 0.1, 0.01],
# Option si léger déséquilibre -> décommente :
# "clf__class_weight": [None, "balanced"]
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
grid = GridSearchCV(svm, param_grid, scoring="f1_macro", cv=cv, n_jobs=-1, verbose=0)
grid.fit(X_train, y_train)
best = grid.best_estimator_
y_pred = best.predict(X_test)
print("đ Best params:", grid.best_params_)
print(f"â
SVM â Acc: {accuracy_score(y_test, y_pred):.3f} | Macro-F1: {f1_score(y_test, y_pred, average='macro'):.3f}")
print(classification_report(y_test, y_pred))
# Matrice de confusion
labels = sorted(np.unique(y_test))
cm = confusion_matrix(y_test, y_pred, labels=labels, )
plt.figure(figsize=(7,6))
plt.imshow(cm, interpolation="nearest")
plt.title("Matrice de confusion â SVM RBF")
plt.xlabel("Prédit"); plt.ylabel("Réel")
plt.xticks(range(len(labels)), labels, rotation=60, ha="right")
plt.yticks(range(len(labels)), labels)
plt.colorbar(fraction=0.046, pad=0.04)
plt.tight_layout()
plt.show()
đ Best params: {'clf__C': 5, 'clf__gamma': 0.1}
â
SVM â Acc: 0.545 | Macro-F1: 0.542
precision recall f1-score support
ADI 0.82 0.76 0.79 78
BACK 0.79 0.76 0.78 80
DEB 0.48 0.51 0.50 80
LYM 0.47 0.80 0.59 80
MUC 0.48 0.39 0.43 82
MUS 0.56 0.47 0.51 79
NORM 0.42 0.40 0.41 78
STR 0.38 0.32 0.34 79
TUM 0.55 0.51 0.53 81
accuracy 0.55 717
macro avg 0.55 0.55 0.54 717
weighted avg 0.55 0.55 0.54 717
# ==========================================================
# đČ RandomForest + importances + ANOVA ranking
# ==========================================================
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import f_classif
rf = RandomForestClassifier(
n_estimators=400, max_depth=None, n_jobs=-1, random_state=42
)
rf.fit(X_train, y_train)
y_pred_rf = rf.predict(X_test)
print(f"â
RF â Acc: {accuracy_score(y_test, y_pred_rf):.3f} | Macro-F1: {f1_score(y_test, y_pred_rf, average='macro'):.3f}")
print(classification_report(y_test, y_pred_rf))
# Confusion matrix
cm_rf = confusion_matrix(y_test, y_pred_rf, labels=labels)
plt.figure(figsize=(7,6))
plt.imshow(cm_rf, interpolation="nearest")
plt.title("Matrice de confusion â RandomForest")
plt.xlabel("Prédit"); plt.ylabel("Réel")
plt.xticks(range(len(labels)), labels, rotation=60, ha="right")
plt.yticks(range(len(labels)), labels)
plt.colorbar(fraction=0.046, pad=0.04)
plt.tight_layout()
plt.show()
# Importances RF (top 15)
importances = rf.feature_importances_
fi = (pd.DataFrame({"feature": feature_cols, "importance": importances})
.sort_values("importance", ascending=False)
.head(15))
plt.figure(figsize=(9,6))
plt.barh(range(len(fi)), fi["importance"].values)
plt.yticks(range(len(fi)), fi["feature"].values)
plt.gca().invert_yaxis()
plt.title("Top importances (RandomForest)")
plt.tight_layout()
plt.show()
# ANOVA F-score (global) â utile pour corroborer
F, pvals = f_classif(X, y)
fs = (pd.DataFrame({"feature": feature_cols, "F": F, "pval": pvals})
.sort_values("F", ascending=False)
.head(15))
display(fi)
display(fs)
â
RF â Acc: 0.615 | Macro-F1: 0.615
precision recall f1-score support
ADI 0.78 0.74 0.76 78
BACK 0.83 0.71 0.77 80
DEB 0.58 0.53 0.55 80
LYM 0.65 0.82 0.73 80
MUC 0.47 0.52 0.49 82
MUS 0.61 0.59 0.60 79
NORM 0.56 0.55 0.55 78
STR 0.50 0.46 0.48 79
TUM 0.60 0.60 0.60 81
accuracy 0.62 717
macro avg 0.62 0.62 0.62 717
weighted avg 0.62 0.62 0.61 717
| feature | importance | |
|---|---|---|
| 2 | mean_roundness | 0.091282 |
| 16 | glcm_homogeneity | 0.081337 |
| 15 | glcm_contrast | 0.080322 |
| 3 | mean_perimeter_x | 0.074988 |
| 7 | mean_perimeter_y | 0.074670 |
| 0 | num_objects | 0.073668 |
| 18 | glcm_correlation | 0.071186 |
| 17 | glcm_energy | 0.070949 |
| 5 | mean_solidity | 0.070213 |
| 6 | mean_extent | 0.069898 |
| 1 | mean_area | 0.069870 |
| 4 | mean_eccentricity | 0.054295 |
| 12 | hu_5 | 0.021974 |
| 10 | hu_3 | 0.021380 |
| 13 | hu_6 | 0.018131 |
| feature | F | pval | |
|---|---|---|---|
| 16 | glcm_homogeneity | 743.925275 | 0.000000e+00 |
| 15 | glcm_contrast | 743.925275 | 0.000000e+00 |
| 17 | glcm_energy | 743.679734 | 0.000000e+00 |
| 7 | mean_perimeter_y | 696.475424 | 0.000000e+00 |
| 0 | num_objects | 482.289503 | 0.000000e+00 |
| 18 | glcm_correlation | 410.063458 | 0.000000e+00 |
| 2 | mean_roundness | 74.931224 | 1.755876e-112 |
| 3 | mean_perimeter_x | 53.647546 | 1.645723e-81 |
| 4 | mean_eccentricity | 52.339870 | 1.467723e-79 |
| 5 | mean_solidity | 50.710547 | 4.021481e-77 |
| 6 | mean_extent | 49.416430 | 3.521475e-75 |
| 13 | hu_6 | 34.622091 | 1.354454e-52 |
| 12 | hu_5 | 32.705359 | 1.280519e-49 |
| 1 | mean_area | 30.486926 | 3.669545e-46 |
| 11 | hu_4 | 15.640486 | 9.673182e-23 |
# ==========================================================
# FIN DE L'ANALYSE INTRA-CLASSE (formes moyennes) -
# SSM: VARIABILITĂ MORPHOLOGIQUE ENTRE CLASSES
# ==========================================================
# ==========================================================
# đ§ Construction du Statistical Shape Model (SSM)
# ==========================================================
from skimage.io import imread
from skimage.measure import label, regionprops
from sklearn.decomposition import PCA
from scipy.ndimage import center_of_mass
import matplotlib.pyplot as plt
import numpy as np
import random
# --- Configuration ---
TARGET_CLASS = "TUM" # à changer selon la classe analysée
SEED = 123
random.seed(SEED)
np.random.seed(SEED)
MASK_DIR = MASKS_DIR / TARGET_CLASS
mask_files = sorted(MASK_DIR.glob("*.tif"))
print(f"đ {len(mask_files)} masques trouvĂ©s pour la classe {TARGET_CLASS}")
# --- SĂ©lection dâun sous-ensemble (pour PCA rapide) ---
MAX_SAMPLES = min(200, len(mask_files))
mask_files = random.sample(mask_files, MAX_SAMPLES)
# --- Chargement des masques binaires ---
masks = []
for f in mask_files:
mask = imread(f)
if mask.ndim == 3:
mask = mask[..., 0]
mask = (mask > 0).astype(np.float32)
masks.append(mask)
masks = np.array(masks)
print("â
Masques chargés :", masks.shape)
# ==========================================================
# đ§© Alignement par centrage du barycentre
# ==========================================================
def align_masks(masks):
aligned = []
H, W = masks.shape[1:]
cy0, cx0 = H // 2, W // 2
for m in masks:
cy, cx = center_of_mass(m)
if np.isnan(cy) or np.isnan(cx):
aligned.append(m)
continue
shift_y, shift_x = int(round(cy0 - cy)), int(round(cx0 - cx))
m_shifted = np.roll(m, shift_y, axis=0)
m_shifted = np.roll(m_shifted, shift_x, axis=1)
aligned.append(m_shifted)
return np.array(aligned)
masks_aligned = align_masks(masks)
print("â
Alignement terminé :", masks_aligned.shape)
# ==========================================================
# âïž PCA sur les formes alignĂ©es
# ==========================================================
flat = masks_aligned.reshape(masks_aligned.shape[0], -1)
pca = PCA(n_components=10, random_state=SEED)
pca.fit(flat)
explained = np.cumsum(pca.explained_variance_ratio_) * 100
print("đ Variance expliquĂ©e cumulĂ©e :", np.round(explained, 2))
# ==========================================================
# đš Visualisation : forme moyenne + premiers modes de variation
# ==========================================================
mean_shape = pca.mean_.reshape(masks_aligned.shape[1:])
modes = [pca.components_[i].reshape(masks_aligned.shape[1:]) for i in range(3)]
fig, axes = plt.subplots(1, 4, figsize=(14, 4))
axes[0].imshow(mean_shape, cmap='gray')
axes[0].set_title("Forme moyenne")
for i in range(3):
delta = 3 * np.sqrt(pca.explained_variance_[i])
axes[i+1].imshow(mean_shape + delta * modes[i], cmap='gray')
axes[i+1].set_title(f"Mode {i+1} (+3Ï)")
for ax in axes:
ax.axis("off")
plt.suptitle(f"SSM â Classe {TARGET_CLASS}", fontsize=14)
plt.tight_layout()
plt.show()
# ==========================================================
# đŸ Sauvegarde des composantes principales
# ==========================================================
np.save(SSM_MODELS_DIR / f"{TARGET_CLASS}_pca_mean.npy", mean_shape)
np.save(SSM_MODELS_DIR / f"{TARGET_CLASS}_pca_components.npy", pca.components_)
np.save(SSM_MODELS_DIR / f"{TARGET_CLASS}_pca_var.npy", pca.explained_variance_)
print(f"đŸ RĂ©sultats sauvegardĂ©s dans {SSM_MODELS_DIR}")
# --- Résumé texte ---
print(f"""
đ§© RĂ©sumĂ© du SSM â {TARGET_CLASS}
- Ăchantillons utilisĂ©s : {len(mask_files)}
- Taille des masques : {masks.shape[1:]}
- Composantes PCA : {pca.n_components}
- Variance cumulée à 3 composantes : {explained[2]:.2f}%
""")
đ 288 masques trouvĂ©s pour la classe TUM â Masques chargĂ©s : (200, 256, 256) â Alignement terminĂ© : (200, 256, 256) đ Variance expliquĂ©e cumulĂ©e : [1.05 1.83 2.54 3.21 3.86 4.47 5.06 5.63 6.2 6.76]
đŸ RĂ©sultats sauvegardĂ©s dans /workspace/models/04_baseline_ssm đ§© RĂ©sumĂ© du SSM â TUM - Ăchantillons utilisĂ©s : 200 - Taille des masques : (256, 256) - Composantes PCA : 10 - Variance cumulĂ©e Ă 3 composantes : 2.54%
đĄ Que fait le SSM
| Ătape | Description |
|---|---|
align_masks() |
recale les masques sur leur barycentre (pas de rotation encore, mais suffisant pour un SSM 2D simple) |
pca.fit(flat) |
décompose la variance spatiale des formes |
mean_shape |
forme moyenne binaire (type âmoyenne des structuresâ) |
modes[i] |
variation principale de forme (épaississement, étirement, etc.) |
explained_variance_ratio_ |
fraction de variance expliquée par chaque mode |
Sauvegarde .npy |
pour réutilisation / visualisation ultérieure |
# ==========================================================
# đ§Ź Analyse morphologique inter-classes (SSM global)
# ==========================================================
from sklearn.decomposition import PCA
from skimage.io import imread
from scipy.ndimage import center_of_mass
import matplotlib.pyplot as plt
import numpy as np
from tqdm import tqdm
import random
# --- ParamĂštres globaux ---
EXCLUDED_CLASSES = ["ADI", "BACK"]
TARGET_CLASSES = [c for c in train_ds.class_to_idx.keys() if c not in EXCLUDED_CLASSES]
MAX_PER_CLASS = 200
SEED = 123
random.seed(SEED)
np.random.seed(SEED)
print(f"đ§Ș Classes incluses dans l'analyse : {TARGET_CLASSES}")
# ==========================================================
# đč Chargement et alignement des masques
# ==========================================================
def align_masks(masks):
"""Centrage du barycentre pour aligner grossiĂšrement les formes."""
aligned = []
H, W = masks.shape[1:]
cy0, cx0 = H // 2, W // 2
for m in masks:
cy, cx = center_of_mass(m)
if np.isnan(cy) or np.isnan(cx):
aligned.append(m)
continue
shift_y, shift_x = int(round(cy0 - cy)), int(round(cx0 - cx))
m_shifted = np.roll(m, shift_y, axis=0)
m_shifted = np.roll(m_shifted, shift_x, axis=1)
aligned.append(m_shifted)
return np.array(aligned)
# --- Chargement de tous les masques ---
X, y = [], []
for cls in TARGET_CLASSES:
cls_dir = MASKS_DIR / cls
mask_files = sorted(cls_dir.glob("*.tif"))
if len(mask_files) == 0:
continue
sample = random.sample(mask_files, min(MAX_PER_CLASS, len(mask_files)))
for f in tqdm(sample, desc=f"Chargement {cls}", leave=False):
mask = imread(f)
if mask.ndim == 3:
mask = mask[..., 0]
mask = (mask > 0).astype(np.float32)
X.append(mask)
y.append(cls)
X = np.array(X)
y = np.array(y)
print(f"â
Masques chargés : {X.shape} (classes={len(np.unique(y))})")
# --- Alignement par barycentre ---
X_aligned = align_masks(X)
# --- Mise Ă plat pour PCA ---
X_flat = X_aligned.reshape(X_aligned.shape[0], -1)
print("â
Forme des données pour PCA :", X_flat.shape)
# ==========================================================
# âïž PCA globale
# ==========================================================
pca = PCA(n_components=2, random_state=SEED)
X_pca = pca.fit_transform(X_flat)
explained = np.cumsum(pca.explained_variance_ratio_) * 100
print(f"đ Variance expliquĂ©e cumulĂ©e (2 composantes) : {explained[-1]:.2f}%")
# ==========================================================
# đš Visualisation : projection morphologique globale
# ==========================================================
try:
from p9dg.utils.class_mappings import class_colors
except Exception:
# fallback au cas oĂč le mapping n'existe pas
import seaborn as sns
palette = sns.color_palette("husl", len(TARGET_CLASSES))
class_colors = {cls: palette[i] for i, cls in enumerate(TARGET_CLASSES)}
plt.figure(figsize=(8,6))
for cls in TARGET_CLASSES:
mask = (y == cls)
color = class_colors.get(cls, "#777777")
plt.scatter(X_pca[mask, 0], X_pca[mask, 1],
label=cls, alpha=0.65, s=15, color=color)
plt.title("Espace morphologique global (PCA sur masques binaires)")
plt.xlabel(f"Composante principale 1 ({pca.explained_variance_ratio_[0]*100:.1f}%)")
plt.ylabel(f"Composante principale 2 ({pca.explained_variance_ratio_[1]*100:.1f}%)")
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=9)
plt.tight_layout()
# --- Sauvegarde ---
fig_path = SAMPLES_DIR / "ssm_global_pca.png"
plt.savefig(fig_path, dpi=150)
plt.show()
print(f"đŸ Figure sauvegardĂ©e : {fig_path}")
đ§Ș Classes incluses dans l'analyse : ['DEB', 'LYM', 'MUC', 'MUS', 'NORM', 'STR', 'TUM']
â Masques chargĂ©s : (1400, 256, 256) (classes=7) â Forme des donnĂ©es pour PCA : (1400, 65536) đ Variance expliquĂ©e cumulĂ©e (2 composantes) : 1.57%
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_global_pca.png
# UMAP sur masques binaires alignés avec distance Jaccard
import numpy as np
from sklearn.metrics import pairwise_distances
import umap
import matplotlib.pyplot as plt
# X_aligned: (N, H, W) et y: labels (déjà créés dans ta cellule SSM globale)
Xb = (X_aligned > 0).astype(np.uint8).reshape(len(X_aligned), -1)
print("âł Matrice des distances (Jaccard) ...")
D = pairwise_distances(Xb, metric="jaccard") # = 1 - IoU pixel
print("âł UMAP (precomputed) ...")
um = umap.UMAP(n_neighbors=30, min_dist=0.1, metric="precomputed", random_state=123)
Z = um.fit_transform(D)
plt.figure(figsize=(8,6))
classes = np.unique(y)
for cls in classes:
m = (y == cls)
plt.scatter(Z[m,0], Z[m,1], s=12, alpha=0.7, label=cls)
plt.title("UMAP (distance Jaccard sur masques binaires)")
plt.legend(bbox_to_anchor=(1.05,1), loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()
âł Matrice des distances (Jaccard) ...
/opt/conda/lib/python3.11/site-packages/sklearn/metrics/pairwise.py:2466: DataConversionWarning: Data was converted to boolean for metric jaccard warnings.warn(msg, DataConversionWarning)
âł UMAP (precomputed) ...
/opt/conda/lib/python3.11/site-packages/umap/umap_.py:1865: UserWarning: using precomputed metric; inverse_transform will be unavailable
warn("using precomputed metric; inverse_transform will be unavailable")
/opt/conda/lib/python3.11/site-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
warn(
from sklearn.manifold import TSNE
tsne = TSNE(n_components=2, metric="precomputed", perplexity=30, learning_rate="auto", init="random", random_state=123)
Z_tsne = tsne.fit_transform(D)
plt.figure(figsize=(8,6))
for cls in classes:
m = (y == cls)
plt.scatter(Z_tsne[m,0], Z_tsne[m,1], s=12, alpha=0.7, label=cls)
plt.title("t-SNE (distance Jaccard sur masques binaires)")
plt.legend(bbox_to_anchor=(1.05,1), loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()
# ==========================================================
# đ€ Classification lĂ©gĂšre sur les descripteurs de forme (PCA)
# ==========================================================
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix
import seaborn as sns
import pandas as pd
# --- Préparation des données ---
# On réutilise X_flat (masques aplatis) et y (étiquettes)
# déjà alignés et filtrés (voir Cellule 6)
print(f"𧟠Données disponibles : {X_flat.shape[0]} échantillons, {X_flat.shape[1]} variables")
# --- PCA étendue pour mieux capturer la variance ---
N_COMPONENTS = 20
pca_full = PCA(n_components=N_COMPONENTS, random_state=SEED)
X_pca_full = pca_full.fit_transform(X_flat)
explained_var = np.cumsum(pca_full.explained_variance_ratio_) * 100
print(f"đ Variance expliquĂ©e cumulĂ©e ({N_COMPONENTS} composantes) : {explained_var[-1]:.2f}%")
# --- Split train/test ---
X_train, X_test, y_train, y_test = train_test_split(
X_pca_full, y, test_size=0.25, random_state=SEED, stratify=y
)
# --- Régression logistique multinomiale ---
clf = LogisticRegression(
multi_class="multinomial",
solver="lbfgs",
max_iter=500,
random_state=SEED
)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
# ==========================================================
# đ RĂ©sultats de classification
# ==========================================================
print("\nđ Rapport de classification (formes binaires â PCA):")
print(classification_report(y_test, y_pred, digits=3))
# --- Matrice de confusion ---
cm = confusion_matrix(y_test, y_pred, labels=TARGET_CLASSES)
cm_df = pd.DataFrame(cm, index=TARGET_CLASSES, columns=TARGET_CLASSES)
plt.figure(figsize=(7,6))
sns.heatmap(cm_df, annot=True, fmt="d", cmap="Blues")
plt.title("Matrice de confusion â SSM baseline (formes binaires)")
plt.ylabel("Vrai label")
plt.xlabel("Label prédit")
plt.tight_layout()
plt.show()
𧟠Données disponibles : 1400 échantillons, 65536 variables
đ Variance expliquĂ©e cumulĂ©e (20 composantes) : 4.24%
đ Rapport de classification (formes binaires â PCA):
precision recall f1-score support
DEB 0.095 0.040 0.056 50
LYM 0.250 0.440 0.319 50
MUC 0.161 0.180 0.170 50
MUS 0.190 0.160 0.174 50
NORM 0.136 0.120 0.128 50
STR 0.137 0.140 0.139 50
TUM 0.229 0.220 0.224 50
accuracy 0.186 350
macro avg 0.171 0.186 0.173 350
weighted avg 0.171 0.186 0.173 350
/opt/conda/lib/python3.11/site-packages/sklearn/linear_model/_logistic.py:1247: FutureWarning: 'multi_class' was deprecated in version 1.5 and will be removed in 1.7. From then on, it will always use 'multinomial'. Leave it to its default value to avoid this warning. warnings.warn(
đ§© PrĂ©paration Ă lâintĂ©gration GAN â Projection morphologique (SSM)¶
Cette section prépare la connexion entre le GAN et le modÚle statistique de forme (SSM).
Lâobjectif est de permettre, une fois le GAN entraĂźnĂ©, de :
- projeter les masques gĂ©nĂ©rĂ©s dans lâespace morphologique rĂ©el,
- comparer leurs formes moyennes et modes de variation,
- mesurer la proximité morphologique entre vraies et fausses classes,
- et identifier dâĂ©ventuelles dĂ©rives structurelles (ex. glissement TUM â MUC).
Les manifests créés précédemment garantissent la traçabilité entre :
- les images réelles (
source_path), - leurs masques (
mask_path), - et bientÎt les masques synthétiques du GAN (
mask_gen_path).
# ==========================================================
# đ§Ź Masques binaires pour images synthĂ©tiques (PixCell â SSM)
# ==========================================================
from pathlib import Path
import os, csv
from tqdm import tqdm
from PIL import Image
import numpy as np
import SimpleITK as sitk
# --- Racine projet (mĂȘme logique que 04_baseline_ssm) ---
PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "..")).resolve()
# Dossier oĂč sont stockĂ©es TES IMAGES SYNTHĂTIQUES PAR CLASSE
# đ adapte ce chemin Ă ton notebook PixCell si besoin
# ex : outputs/08_adapter_lora/samples/<classe>/*.png
GAN_SAMPLES_DIR = PROJECT_ROOT / "outputs" / "07_diffusion_model/" / "pixcell_out_histo"
# Dossier oĂč lâon va Ă©crire les masques synthĂ©tiques
# (câest celui que la cellule "projection GAN â SSM" utilisera ensuite)
SSM_OUTPUTS_DIR = PROJECT_ROOT / "outputs" / "04_baseline_ssm"
MASKS_SYNTH_DIR = SSM_OUTPUTS_DIR / "gan" / "masks"
MASKS_SYNTH_DIR.mkdir(parents=True, exist_ok=True)
print(f"â
GAN_SAMPLES_DIR : {GAN_SAMPLES_DIR}")
print(f"â
MASKS_SYNTH_DIR : {MASKS_SYNTH_DIR}")
# --- Helper : PIL RGB â masque binaire SimpleITK (Otsu) ---
def create_mask_from_pil(pil_img: Image.Image):
"""Convertit une tuile RGB en masque binaire via seuil d'Otsu (SimpleITK)."""
arr = np.array(pil_img, dtype=np.uint8) # (H, W, 3)
img_sitk = sitk.GetImageFromArray(arr, isVector=True)
gray = sitk.VectorIndexSelectionCast(img_sitk, 0)
mask = sitk.OtsuThreshold(gray, 0, 1)
return mask
# --- Boucle par classe sur les dossiers d'images synthétiques ---
for cls_name in sorted(os.listdir(GAN_SAMPLES_DIR)):
class_dir = GAN_SAMPLES_DIR / cls_name
if not class_dir.is_dir():
continue
out_dir = MASKS_SYNTH_DIR / cls_name
out_dir.mkdir(parents=True, exist_ok=True)
manifest_path = out_dir / "manifest.csv"
rows = []
# On accepte plusieurs formats dâimage (adapte si nĂ©cessaire)
img_paths = sorted(
list(class_dir.glob("*.png"))
+ list(class_dir.glob("*.jpg"))
+ list(class_dir.glob("*.jpeg"))
+ list(class_dir.glob("*.tif"))
)
if not img_paths:
print(f"â ïž Aucun fichier image trouvĂ© pour {cls_name} dans {class_dir}")
continue
for img_path in tqdm(img_paths, desc=f"Masques synth â {cls_name}"):
pil = Image.open(img_path).convert("RGB")
mask = create_mask_from_pil(pil)
# MĂȘme convention de nom que pour le rĂ©el
mask_path = out_dir / f"{img_path.stem}__mask.tif"
sitk.WriteImage(mask, str(mask_path))
rows.append({
"mask_path": str(mask_path),
"source_path": str(img_path),
})
# Manifest CSV pour cette classe
with open(manifest_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["mask_path", "source_path"])
writer.writeheader()
writer.writerows(rows)
print(f"â
{len(rows)} masques synthétiques + manifest : {manifest_path}")
print(f"\nâ
Tous les masques synthétiques sont dans : {MASKS_SYNTH_DIR}")
â GAN_SAMPLES_DIR : /workspace/outputs/07_diffusion_model/pixcell_out_histo â MASKS_SYNTH_DIR : /workspace/outputs/04_baseline_ssm/gan/masks
Masques synth â ADI: 100%|ââââââââââ| 500/500 [00:02<00:00, 173.05it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/ADI/manifest.csv
Masques synth â BACK: 100%|ââââââââââ| 500/500 [00:02<00:00, 191.22it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/BACK/manifest.csv
Masques synth â DEB: 100%|ââââââââââ| 500/500 [00:02<00:00, 186.37it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/DEB/manifest.csv
Masques synth â LYM: 100%|ââââââââââ| 500/500 [00:02<00:00, 187.30it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/LYM/manifest.csv
Masques synth â MUC: 100%|ââââââââââ| 500/500 [00:02<00:00, 189.59it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/MUC/manifest.csv
Masques synth â MUS: 100%|ââââââââââ| 500/500 [00:02<00:00, 190.53it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/MUS/manifest.csv
Masques synth â NORM: 100%|ââââââââââ| 500/500 [00:02<00:00, 184.61it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/NORM/manifest.csv
Masques synth â STR: 100%|ââââââââââ| 500/500 [00:02<00:00, 187.58it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/STR/manifest.csv
Masques synth â TUM: 100%|ââââââââââ| 500/500 [00:02<00:00, 183.07it/s]
â 500 masques synthĂ©tiques + manifest : /workspace/outputs/04_baseline_ssm/gan/masks/TUM/manifest.csv â Tous les masques synthĂ©tiques sont dans : /workspace/outputs/04_baseline_ssm/gan/masks
# ==========================================================
# đ§Ź Ăvaluation morphologique des masques synthĂ©tiques (GAN â SSM)
# ==========================================================
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
from skimage.io import imread
from skimage.transform import resize
from scipy.ndimage import center_of_mass
# --- Chemins (cohérents avec ton notebook + ce que tu viens d'exécuter) ---
PROJECT_ROOT = Path("/workspace").resolve()
MODELS_DIR = PROJECT_ROOT / "models" / "04_baseline_ssm"
MASKS_REAL_DIR = PROJECT_ROOT / "outputs" / "04_baseline_ssm" / "masks_sitk"
MASKS_SYNTH_DIR = PROJECT_ROOT / "outputs" / "04_baseline_ssm" / "gan" / "masks"
SAMPLES_DIR = PROJECT_ROOT / "samples" / "04_baseline_ssm"
SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
# Si tu as déjà une liste globale de classes dans le notebook, tu peux la réutiliser
CLASSES = ["ADI", "BACK", "DEB", "LYM", "MUC", "MUS", "NORM", "STR", "TUM"]
SEED = 123
rng = np.random.default_rng(SEED)
# ----------------------------------------------------------
# Helpers : alignement + chargement des masques
# ----------------------------------------------------------
def align_masks(masks: np.ndarray) -> np.ndarray:
"""
Aligne chaque masque sur son barycentre (translation uniquement).
masks : (N, H, W), valeurs 0/1.
"""
aligned = []
H, W = masks.shape[1:]
cy_ref, cx_ref = H // 2, W // 2
for m in masks:
if m.max() == 0:
aligned.append(m)
continue
cy, cx = center_of_mass(m)
dy = int(round(cy_ref - cy))
dx = int(round(cx_ref - cx))
m_shift = np.zeros_like(m)
ys = slice(max(0, dy), min(H, H + dy))
xs = slice(max(0, dx), min(W, W + dx))
ys_src = slice(max(0, -dy), max(0, -dy) + (ys.stop - ys.start))
xs_src = slice(max(0, -dx), max(0, -dx) + (xs.stop - xs.start))
m_shift[ys, xs] = m[ys_src, xs_src]
aligned.append(m_shift)
return np.stack(aligned, axis=0)
def load_and_align_masks(mask_dir: Path,
max_samples: int = 200,
target_shape: tuple[int, int] | None = None) -> np.ndarray | None:
"""
Charge des masques binaires dans un dossier, les met au bon format
et les aligne sur leur barycentre.
"""
mask_paths = sorted([p for p in mask_dir.glob("*.tif")] +
[p for p in mask_dir.glob("*.png")])
if not mask_paths:
return None
rng.shuffle(mask_paths)
mask_paths = mask_paths[:max_samples]
masks = []
for p in mask_paths:
m = imread(str(p))
if m.ndim == 3:
m = m[..., 0]
m = (m > 0).astype(np.float32)
if target_shape is not None and m.shape != target_shape:
m = resize(
m,
target_shape,
order=0, # nearest-neighbour pour rester binaire
preserve_range=True,
anti_aliasing=False
).astype(np.float32)
masks.append(m)
masks = np.stack(masks, axis=0)
return align_masks(masks)
# ----------------------------------------------------------
# Helpers : projection + distance de Fréchet morphologique
# ----------------------------------------------------------
def project_in_ssm(masks: np.ndarray,
pca_mean: np.ndarray,
pca_comps: np.ndarray,
n_components: int = 5) -> np.ndarray:
"""
Projette des masques alignés dans l'espace PCA du SSM.
- masks : (N, H, W)
- pca_mean : (H, W)
- pca_comps : (n_components_full, H*W)
"""
H, W = masks.shape[1:]
flat = masks.reshape(len(masks), -1) # (N, H*W)
mean_flat = pca_mean.reshape(-1) # (H*W,)
comps = pca_comps[:n_components] # (k, H*W)
Z = (flat - mean_flat) @ comps.T # (N, k)
return Z
def _sqrtm_psd(A: np.ndarray) -> np.ndarray:
"""Racine de matrice pour PSD via décomposition propre."""
w, V = np.linalg.eigh(A)
w = np.clip(w, 0, None)
return (V * np.sqrt(w)) @ V.T
def frechet_morpho(F1: np.ndarray, F2: np.ndarray) -> float:
"""
Distance de Fréchet dans l'espace SSM (analogue FID, mais sur les coordonnées PCA de forme).
F1, F2 : (N, d)
"""
mu1, mu2 = F1.mean(0), F2.mean(0)
C1, C2 = np.cov(F1, rowvar=False), np.cov(F2, rowvar=False)
diff = mu1 - mu2
covmean = _sqrtm_psd(C1 @ C2)
return float(diff @ diff + np.trace(C1 + C2 - 2.0 * covmean))
# ----------------------------------------------------------
# Fonction d'évaluation pour une classe
# ----------------------------------------------------------
def evaluate_class_in_ssm(cls_name: str,
max_real: int = 200,
max_synth: int = 200,
n_components_ssm: int = 5):
print(f"\n==============================")
print(f"đ Classe : {cls_name}")
print(f"==============================")
real_dir = MASKS_REAL_DIR / cls_name
synth_dir = MASKS_SYNTH_DIR / cls_name
if not real_dir.exists():
print(f"â ïž Pas de masques rĂ©els pour {cls_name} dans {real_dir} â on saute.")
return None
if not synth_dir.exists():
print(f"â ïž Pas de masques synthĂ©tiques pour {cls_name} dans {synth_dir} â on saute.")
return None
# 1) Charger et aligner les masques réels (référence)
masks_real = load_and_align_masks(real_dir, max_samples=max_real, target_shape=None)
if masks_real is None:
print(f"â ïž Aucun masque rĂ©el lisible pour {cls_name}.")
return None
H, W = masks_real.shape[1:]
print(f" â
Masques réels alignés : {masks_real.shape}")
# 2) Charger et aligner les masques synthétiques (GAN/PixCell)
masks_synth = load_and_align_masks(synth_dir, max_samples=max_synth, target_shape=(H, W))
if masks_synth is None:
print(f"â ïž Aucun masque synthĂ©tique lisible pour {cls_name}.")
return None
print(f" â
Masques synthétiques alignés : {masks_synth.shape}")
# 3) Charger (ou construire) le SSM pour cette classe
mean_path = MODELS_DIR / f"{cls_name}_pca_mean.npy"
comps_path = MODELS_DIR / f"{cls_name}_pca_components.npy"
var_path = MODELS_DIR / f"{cls_name}_pca_var.npy"
if not (mean_path.exists() and comps_path.exists() and var_path.exists()):
print(f" â ïž Pas de SSM existant pour {cls_name} dans {MODELS_DIR}, on le construit Ă partir des masques rĂ©els.")
# Flatten des masques alignés
flat = masks_real.reshape(len(masks_real), -1) # (N, H*W)
# Nombre de composantes PCA (tu peux ajuster)
N_COMPONENTS = min(16, flat.shape[0]) # max 16 ou N-1
pca = PCA(
n_components=N_COMPONENTS,
svd_solver="randomized",
random_state=SEED,
)
pca.fit(flat)
# Mean + composantes + variance expliquée
pca_mean = pca.mean_.reshape(H, W)
pca_comps = pca.components_
pca_var = pca.explained_variance_ratio_
MODELS_DIR.mkdir(parents=True, exist_ok=True)
np.save(mean_path, pca_mean.astype(np.float32))
np.save(comps_path, pca_comps.astype(np.float32))
np.save(var_path, pca_var.astype(np.float32))
print(f" â
SSM entraßné et sauvegardé pour {cls_name} ({N_COMPONENTS} composantes).")
else:
pca_mean = np.load(mean_path)
pca_comps = np.load(comps_path)
pca_var = np.load(var_path)
print(f" â
SSM chargé pour {cls_name} depuis {MODELS_DIR}")
# 4) Projection dans l'espace SSM
real_proj = project_in_ssm(masks_real, pca_mean, pca_comps, n_components=n_components_ssm)
synth_proj = project_in_ssm(masks_synth, pca_mean, pca_comps, n_components=n_components_ssm)
# 5) Métriques morphologiques
morph_fid = frechet_morpho(real_proj, synth_proj)
radius_real = np.linalg.norm(real_proj, axis=1)
radius_synth = np.linalg.norm(synth_proj, axis=1)
print(f" đ FrĂ©chet morphologique (SSM-FID) : {morph_fid:.4f}")
print(f" Rayon moyen RĂEL : {radius_real.mean():.3f} ± {radius_real.std():.3f}")
print(f" Rayon moyen SYNTHĂTIQUE: {radius_synth.mean():.3f} ± {radius_synth.std():.3f}")
# 6) Visualisation 2D (PC1 vs PC2)
if real_proj.shape[1] >= 2:
plt.figure(figsize=(7, 6))
plt.scatter(real_proj[:, 0], real_proj[:, 1],
s=10, alpha=0.6, label="RĂEL")
plt.scatter(synth_proj[:, 0], synth_proj[:, 1],
s=10, alpha=0.6, label="SYNTHĂTIQUE")
plt.xlabel(f"PC1 (var={pca_var[0]:.2f})")
plt.ylabel(f"PC2 (var={pca_var[1]:.2f})")
plt.title(f"Projection morphologique (SSM) â {cls_name}")
plt.legend()
plt.tight_layout()
fig_path = SAMPLES_DIR / f"ssm_gan_projection_{cls_name}.png"
plt.savefig(fig_path, dpi=150)
plt.show()
print(f" đŸ Figure sauvegardĂ©e : {fig_path}")
return {
"class": cls_name,
"morph_fid": morph_fid,
"radius_real_mean": float(radius_real.mean()),
"radius_real_std": float(radius_real.std()),
"radius_synth_mean": float(radius_synth.mean()),
"radius_synth_std": float(radius_synth.std()),
}
# ----------------------------------------------------------
# đ Boucle sur toutes les classes
# ----------------------------------------------------------
results = []
for cls in CLASSES:
res = evaluate_class_in_ssm(cls)
if res is not None:
results.append(res)
import pandas as pd
df_morph = pd.DataFrame(results)
display(df_morph)
# Option : sauvegarder le tableau des métriques
metrics_path = PROJECT_ROOT / "metrics" / "04_baseline_ssm_morph_gan.csv"
metrics_path.parent.mkdir(parents=True, exist_ok=True)
df_morph.to_csv(metrics_path, index=False)
print(f"\nđ MĂ©triques morphologiques sauvegardĂ©es dans : {metrics_path}")
==============================
đ Classe : ADI
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour ADI dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour ADI (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 8257.2286
Rayon moyen RĂEL : 74.940 ± 28.055
Rayon moyen SYNTHĂTIQUE: 73.634 ± 14.815
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_ADI.png
==============================
đ Classe : BACK
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour BACK dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour BACK (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 7103.7037
Rayon moyen RĂEL : 74.673 ± 35.126
Rayon moyen SYNTHĂTIQUE: 86.215 ± 33.642
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_BACK.png
==============================
đ Classe : DEB
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour DEB dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour DEB (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 500.5504
Rayon moyen RĂEL : 24.102 ± 9.525
Rayon moyen SYNTHĂTIQUE: 29.305 ± 19.767
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_DEB.png
==============================
đ Classe : LYM
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour LYM dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour LYM (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 378.4565
Rayon moyen RĂEL : 22.987 ± 7.661
Rayon moyen SYNTHĂTIQUE: 19.963 ± 10.165
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_LYM.png
==============================
đ Classe : MUC
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour MUC dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour MUC (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 311.1837
Rayon moyen RĂEL : 23.733 ± 12.468
Rayon moyen SYNTHĂTIQUE: 26.260 ± 12.494
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_MUC.png
==============================
đ Classe : MUS
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour MUS dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour MUS (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 1970.2124
Rayon moyen RĂEL : 27.577 ± 12.856
Rayon moyen SYNTHĂTIQUE: 48.240 ± 23.726
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_MUS.png
==============================
đ Classe : NORM
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour NORM dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour NORM (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 180.5749
Rayon moyen RĂEL : 24.785 ± 11.221
Rayon moyen SYNTHĂTIQUE: 23.402 ± 10.721
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_NORM.png
==============================
đ Classe : STR
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â ïž Pas de SSM existant pour STR dans /workspace/models/04_baseline_ssm, on le construit Ă partir des masques rĂ©els.
â
SSM entraßné et sauvegardé pour STR (16 composantes).
đ FrĂ©chet morphologique (SSM-FID) : 1167.2942
Rayon moyen RĂEL : 24.975 ± 8.546
Rayon moyen SYNTHĂTIQUE: 36.293 ± 19.627
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_STR.png
==============================
đ Classe : TUM
==============================
â
Masques réels alignés : (200, 256, 256)
â
Masques synthétiques alignés : (200, 256, 256)
â
SSM chargé pour TUM depuis /workspace/models/04_baseline_ssm
đ FrĂ©chet morphologique (SSM-FID) : 161.7982
Rayon moyen RĂEL : 17.820 ± 12.974
Rayon moyen SYNTHĂTIQUE: 22.083 ± 11.711
đŸ Figure sauvegardĂ©e : /workspace/samples/04_baseline_ssm/ssm_gan_projection_TUM.png
| class | morph_fid | radius_real_mean | radius_real_std | radius_synth_mean | radius_synth_std | |
|---|---|---|---|---|---|---|
| 0 | ADI | 8257.228640 | 74.939545 | 28.054674 | 73.633583 | 14.814732 |
| 1 | BACK | 7103.703697 | 74.673172 | 35.125595 | 86.215027 | 33.642223 |
| 2 | DEB | 500.550378 | 24.101524 | 9.525430 | 29.304726 | 19.766836 |
| 3 | LYM | 378.456517 | 22.986877 | 7.660619 | 19.962627 | 10.164616 |
| 4 | MUC | 311.183715 | 23.732832 | 12.467725 | 26.259520 | 12.494127 |
| 5 | MUS | 1970.212376 | 27.576719 | 12.855993 | 48.239540 | 23.725590 |
| 6 | NORM | 180.574918 | 24.785250 | 11.221463 | 23.402342 | 10.721470 |
| 7 | STR | 1167.294183 | 24.975256 | 8.545846 | 36.292610 | 19.626957 |
| 8 | TUM | 161.798245 | 17.820433 | 12.974398 | 22.083372 | 11.711346 |
đ MĂ©triques morphologiques sauvegardĂ©es dans : /workspace/metrics/04_baseline_ssm_morph_gan.csv
Analyse pour la classe TUM¶
1. Couverture morphologique : plutÎt bon signe¶
Sur le scatter TUM :
- Les points synthétiques (orange) se mélangent bien aux réels (bleu) autour du centre.
- Il nây a pas de collapse Ă©vident : les synthĂ©tiques ne sont ni regroupĂ©s dans une petite boule minuscule, ni confinĂ©s dans un coin isolĂ©.
Autrement dit, PixCell gĂ©nĂšre des formes TUM qui vivent globalement dans le mĂȘme espace morphologique que les TUM rĂ©elles.
â Câest un bon prĂ©requis pour dire que la classe TUM nâest pas complĂštement « fantaisiste ».
2. Biais et dĂ©rive : lĂ oĂč ça commence Ă dĂ©vier¶
Visuellement et numériquement :
On observe un « bras » orange sur la droite (PC1 > 0) oĂč il y a moins de points bleus :
cela ressemble à un mode morphologique sur-représenté par le générateur (certains patterns de TUM que PixCell « aime trop »).Les synthétiques restent présents au centre, mais explorent davantage un quadrant que les réels :
potentiellement des formes un peu plus grandes / allongées / excentrées en moyenne.
Cela correspond bien Ă lâidĂ©e suivante :
le modĂšle a appris la bonne famille de formes,
mais avec un biais sur certains sous-types morphologiques de TUM.
đ§Ÿ Bilan â DĂ©marche morphologique et Statistical Shape Model (SSM)¶
đč 1. Objectif gĂ©nĂ©ral¶
Lâobjectif de cette Ă©tape Ă©tait dâĂ©valuer la variabilitĂ© morphologique inter-classe dans le dataset NCT-CRC-HE-100K, en se concentrant sur la forme plutĂŽt que sur la texture.
LâidĂ©e : construire une baseline morphologique solide avant dâaborder le modĂšle statistique de forme (SSM), futur support dâexplicabilitĂ© pour les modĂšles gĂ©nĂ©ratifs (GANs).
đč 2. Ătapes rĂ©alisĂ©es¶
Ătape 1 â GĂ©nĂ©ration des masques binaires¶
- Utilisation du générateur
HistoDatasetpour produire un jeu équilibré par classe. - Conversion en masques binaires via
SimpleITK(seuillage dâOtsu). - CrĂ©ation dâun manifest CSV par classe, assurant la traçabilitĂ© entre image rĂ©elle et masque.
â RĂ©sultat : une base de masques cohĂ©rente, normalisĂ©e et exploitable pour lâanalyse morphologique.
Ătape 2 â Descripteurs morphologiques (SimpleITK)¶
- Calcul de propriétés géométriques de base :
num_objects,mean_area,mean_roundness,mean_perimeter.
- Quelques différences observées entre classes (ex. TUM et LYM plus fragmentées).
â ïž Constat : signal cohĂ©rent mais faiblement discriminant â les formes restent superposĂ©es entre classes.
Ătape 3 â Enrichissement morpho-textural (scikit-image)¶
- Ajout de descripteurs avancés :
- Forme :
eccentricity,solidity,extent,perimeter - Moments invariants de Hu : invariance rotation/échelle
- Texture GLCM : contraste, homogénéité, énergie, corrélation
- Entropie locale (optionnelle)
- Forme :
- Fusion SimpleITK + scikit-image â DataFrame complet
df_morpho_ext.
â
RĂ©sultat : des diffĂ©rences plus nettes (GLCM, Hu moments, nombre dâobjets, aire moyenne).
Certaines classes (LYM, TUM) montrent un signal morphologique stable et identifiable.
Ătape 4 â Classification sur df_morpho_ext¶
- EntraĂźnement dâun SVM RBF et dâun RandomForest sur les features morpho-texturales.
- Les tests initiaux utilisaient un split unique (risque de fuite inter-sources).
â AprĂšs correction avec split anti-fuite (utilisation du dossier ),
la baseline morphologique atteint ~0.74 dâaccuracy et de macro-F1.
| Classe | F1-score | Observation |
|---|---|---|
| ADI / BACK | 0.82â0.87 | Formes triviales, bien sĂ©parĂ©es |
| LYM / TUM | 0.70â0.75 | Structure cellulaire dense, signal fort |
| MUC / STR / DEB / MUS / NORM | 0.55â0.65 | Classes proches morphologiquement |
| Global | Accuracy â 0.74 â Macro-F1 â 0.74 | Baseline robuste et rĂ©aliste |
đš Les matrices de confusion montrent :
- ADI/BACK parfaitement séparées,
- LYM et TUM bien différenciées,
- MUC/STR/DEB encore confondues (formes proches).
Ătape 5 â Statistical Shape Model (SSM)¶
- Alignement par centrage du barycentre.
- PCA sur les formes binaires â variance expliquĂ©e (2 composantes) â 1.5 %.
- Tentatives de projection non linĂ©aire (t-SNE / UMAP) â peu concluantes.
- Visualisation : fort chevauchement entre classes, structure morphologique non linéaire.
â ïž Constat : le SSM linĂ©aire (PCA sur pixels) ne capture pas la complexitĂ© des formes histologiques :
- absence de rotation/échelle (alignement partiel),
- déformations non linéaires,
- morphologies trop complexes pour un espace PCA.
đč 3. Conclusions et enseignements¶
| Bloc | Apport | Limites |
|---|---|---|
| Masques binaires (SimpleITK) | Pipeline reproductible et traçable | Information morphologique limitée |
| scikit-image (forme + texture) | Signal discriminant fort, bon compromis complexité/performance | Sensible au seuillage et à la rotation |
| SSM linéaire (PCA) | Base explicative et visualisation des modes de forme | Variance trÚs faible, non-linéarité ignorée |
| SVM/RF morphologiques | Baseline solide (~0.74 Macro-F1) | Performances plafonnées sur classes proches |
đč 4. RĂŽle du SSM pour la suite¶
Le SSM nâest pas un classifieur, mais un outil dâexplicabilitĂ© :
- il servira Ă projeter les masques gĂ©nĂ©rĂ©s par le GAN dans lâespace morphologique rĂ©el,
- à quantifier la proximité de forme entre vraies et fausses classes,
- et Ă visualiser la couverture morphologique (dispersion, modes dominants).
âĄïž Le SSM devient la boussole morphologique du GAN : il permettra dâexpliquer, de contrĂŽler et de valider la cohĂ©rence structurelle des images synthĂ©tiques.
đč 5. Suites logiques¶
- Séparation stricte des sources avant tout apprentissage (anti-fuite).
- Alignement renforcé des formes (Procrustes complet : translation + rotation + scale).
- Représentations non linéaires (contours, Fourier, autoencoder de forme).
- Fusion morpho + SSM dans un modĂšle multivue pour les comparaisons GAN.
- IntĂ©gration au pipeline GAN : projection des masques synthĂ©tiques dans lâespace SSM pour Ă©valuer la fidĂ©litĂ© morphologique.
đ§ En rĂ©sumĂ©, le travail morphologique a permis :
- de poser une baseline robuste sur les descripteurs scikit-image,
- de démontrer les limites du SSM linéaire pour la classification,
- et surtout de prĂ©parer un outil dâexplicabilitĂ© morphologique rĂ©utilisable dans le futur GAN.
Addendum: Utilisation de ssm comme métrique pour les images GAN (UNI2-h+Pixcell gated)¶
Analyse morphologique des masques synthétiques (SSM)¶
Nous avons Ă©valuĂ© la cohĂ©rence morphologique des images synthĂ©tiques gĂ©nĂ©rĂ©es par PixCell en projetant les masques binaires (rĂ©els et synthĂ©tiques) dans un espace statistique de formes (SSM) appris par PCA sur les masques rĂ©els de chaque classe. Pour chaque classe, nous comparons la distribution des coordonnĂ©es PCA via un FrĂ©chet morphologique (analogue au FID, mais dans lâespace des formes) ainsi que le rayon moyen (distance Ă la forme moyenne) et son Ă©cart-type.
Globalement, plusieurs classes montrent une bonne cohĂ©rence morphologique entre rĂ©els et synthĂ©tiques. Câest le cas notamment de TUM, NORM et MUC, qui prĂ©sentent des FrĂ©chet morphologiques modĂ©rĂ©s (â160â310) et des rayons moyens proches (Ă©cart relatif < 25 %). Les masques synthĂ©tiques de ces classes restent donc dans le mĂȘme espace de formes que les masques rĂ©els, avec seulement un lĂ©ger biais vers des morphologies un peu plus marquĂ©es (TUM/MUC) ou lĂ©gĂšrement plus compactes (NORM).
Dâautres classes, comme LYM et DEB, restent raisonnablement proches du rĂ©el mais prĂ©sentent un dĂ©calage systĂ©matique :
- les lymphocytes (LYM) sont gĂ©nĂ©rĂ©s sous forme lĂ©gĂšrement plus compacte (rayon synthĂ©tique â 0,87Ă le rayon rĂ©el) ;
- les dĂ©bris (DEB) sont au contraire un peu plus Ă©tendus (â 1,22Ă).
En revanche, les classes STR et MUS montrent une dĂ©rive morphologique nette (FrĂ©chet morphologique > 1000), avec des rayons synthĂ©tiques nettement supĂ©rieurs aux rayons rĂ©els (â 1,45Ă pour STR, â 1,75Ă pour MUS). Les formes gĂ©nĂ©rĂ©es pour ces tissus stromaux / musculaires tendent donc Ă exagĂ©rer la morphologie observĂ©e dans les donnĂ©es rĂ©elles.
Enfin, les classes ADI et BACK apparaissent clairement problĂ©matiques : les distances de FrĂ©chet sont trĂšs Ă©levĂ©es (> 7000) et, dans le cas dâADI, la variabilitĂ© morphologique des masques synthĂ©tiques est fortement rĂ©duite par rapport au rĂ©el, ce qui suggĂšre un effet de collapse sur un sous-ensemble de formes. Pour BACK, les masques synthĂ©tiques sont en moyenne plus grands que les masques rĂ©els, ce qui traduit Ă©galement un dĂ©calage de distribution.
Ces rĂ©sultats suggĂšrent que les images synthĂ©tiques sont morphologiquement plausibles et bien calibrĂ©es pour certaines classes (TUM, NORM, MUC en particulier), tandis que dâautres classes (STR, MUS, ADI, BACK) prĂ©sentent une distribution de formes significativement dĂ©calĂ©e. Dans la suite, nous croisons ces mĂ©triques morphologiques avec la performance du classifieur CNN baseline afin dâĂ©valuer, de maniĂšre downstream, lâimpact rĂ©el de ces biais de forme sur les prĂ©dictions du modĂšle.